在上一篇文章中,我們提到將透過「緩存」的機制來解決 computed
在訪問時重複執行的問題。
在 Vue 3 的原始碼裡,computed
是靠一個「髒值標記(dirty flag)」來判斷需不需要重新計算的。
在 computed 中記錄髒標記:當髒標記是 true,才需要進行更新;當髒標記是 false,則表示需要進行緩存。
class ComputedRefImpl implements Dependency, Sub {
...
...
tracking = false
// 計算屬性是否需要重新計算,如果為 true,則重新計算
dirty = true
...
...
get value() {
if(this.dirty){
this.update()
}
...
...
}
update(){
...
...
try {
this._value = this.fn()
// 調用 update 更新後,將 dirty 更改為 false
this.dirty = false
} finally {
endTrack(this)
setActiveSub(prevSub)
}
}
}
再回去看,現在已經有進行緩存,只執行兩次,可是我們又發現了另一個問題,如果你把 index.html
設定為以下:
<body>
<div id="app"></div>
<script type="module">
// import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
import { ref, computed, effect } from '../dist/reactivity.esm.js'
const count = ref(0)
const c = computed(() => {
console.log('computed')
return count.value + 1
})
// effect(() => {
// console.log(c.value)
// })
console.log(c.value)
count.value = 1
</script>
</body>
你會發現 count.value
數值變更之後,他還是訪問 computed
,但是依賴 computed
的數值被變更時,我們當下不一定會訪問 computed
。
查看一下官方程式碼,count.value
數值變更後,computed
沒有被訪問。
但是我們的版本,他又再訪問一次computed
:
遇到這個狀況我們可以怎麼做?我們可以做髒標記,等下次computed
被 effect 訪問再執行更新。
//system.ts
...
...
export function processComputedUpdate(sub) {
// 有 sub.subs(effect 鏈表的頭節點),再進行更新
if(sub.subs){
sub.update()
propagate(sub.subs)
}
}
export function propagate(subs) {
let link = subs
let queuedEffect = []
while (link) {
const sub = link.sub
if(!sub.tracking){
if ('update' in sub) {
// 被 effect 進行訪問,計算屬性需要重新計算
sub.dirty = true
processComputedUpdate(sub)
} else {
queuedEffect.push(sub)
}
}
link = link.nextSub
}
queuedEffect.forEach(effect => effect.notify())
}
...
...
這樣就可以解決緩存的問題。可是我們又發現了新的問題。
<body>
<div id="app"></div>
<script type="module">
// import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
import { ref, computed, effect } from '../dist/reactivity.esm.js'
const count = ref(0)
const c = computed(() => {
console.log('computed')
return count.value * 0
})
effect(() => {
console.log(c.value)
})
setTimeout(() => {
count.value = 1
}, 1000)
</script>
</body>
現在我們發現,computed 執行兩次,這個沒什麼問題,有問題是 effect 的數值沒有改動,但是它也執行兩次,如果數值沒變,只要執行一次就好了。
回顧我們在 Ref 實作,當觸發更新時,也是新值和舊值不相同的時候,才會觸發更新,在這邊我們也用相同作法。
//computed.ts
import { hasChanged } from '@vue/shared'
...
...
class ComputedRefImpl implements Dependency, Sub {
...
...
update(){
...
...
try {
// 更新前的值
const oldValue = this._value
// 更新的值
this._value = this.fn()
this.dirty = false
return hasChanged(oldValue, this._value)
} finally {
endTrack(this)
setActiveSub(prevSub)
}
}
}
先將更新前的值保存起來,用hasChanged
判斷數值是否改變,再從 update 函式返回值判斷:
//system.ts
export function processComputedUpdate(sub) {
// update 返回值如果是 true
// 表示數值不同,effect 執行
if(sub.subs && sub.update()){
propagate(sub.subs)
}
}
得到期望結果,computed
執行兩次、effect
執行一次。
感覺我們解決了這個問題,但其實發現這個只是非常片面的解決方案,因為 effect 它在訪問相同依賴的時候,會重複觸發。
<body>
<div id="app"></div>
<script type="module">
// import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
import { ref, computed, effect } from '../dist/reactivity.esm.js'
const count = ref(0)
effect(() => {
console.count('effect')
console.log(count.value)
count.value
})
setTimeout(() => {
count.value = 1
}, 1000)
</script>
</body>
可以看到這樣它觸發了三次,你如果查看一下 count:
effect(() => {
console.count('effect')
console.log(count.value)
count.value
})
console.log(count)
會發現它收集了相同依賴收集兩次,這時候該怎麼解決?
原始碼在link
函式裡面,每次建立關聯關係前都會去遍歷鏈表,確認是不是有建立過關聯關係。
export function link(dep, sub) {
/**
* 復用節點
* sub.depsTail 是 undefined,並且有 sub.deps 頭節點,表示要復用
*/
const currentDep = sub.depsTail
const nextDep = currentDep === undefined ? sub.deps : currentDep.nextDep
// 如果 nextDep.dep 等於我當前要收集的 dep
if (nextDep && nextDep.dep === dep) {
sub.depsTail = nextDep // 移動指針
return
}
/**
* 如果 dep 和 sub 建立過關聯關係,就直接返回。
*/
let existingLink = sub.deps;
while (existingLink) {
// 如果在鏈表中找到了與當前 dep 相同的依賴項
if (existingLink.dep === dep) {
// 表示這個關聯已經建立過了,直接返回,不做任何事
return;
}
// 移動到下一個依賴項節點
existingLink = existingLink.nextDep;
}
...
...
}
link
函式一開始,就先進行檢查。sub.deps
(訂閱者的依賴鏈表頭部) 開始進行遍歷。
nextDep
指標移動,檢查每一個鏈表節點 (Link
)。link.dep
是否與我們正要連結的 dep
是同一個。return
。這邊注意,需要寫在復用節點的邏輯後面:若檢查建立過依賴關係時提前退出,depsTail 標記會一直保持是
undefined
,依賴會被錯誤清理。
這邊換另一個比較簡易一點的方法,我們不管他們是不是有建立過關聯關係,重點是我們只要讓 effect 函式執行一次就可以了,這樣我們只要調整髒標記處理。
//effect.ts
export class ReactiveEffect {
...
...
dirty = true // 是否需要重新計算
...
我們先在 effect 函式,加一個髒標記。
//system.ts
export function propagate(subs) {
...
...
// 不在執行中的才加入隊列 以及 他是髒標記是 false 才執行
if(!sub.tracking && !sub.dirty){
// 開始執行,髒標記設定為初始值
sub.dirty = true
if ('update' in sub) {
processComputedUpdate(sub)
} else {
queuedEffect.push(sub)
}
}
...
...
}
export function endTrack(sub) {
sub.tracking = false // 執行結束,取消標記
const depsTail = sub.depsTail
sub.dirty = false // fn 執行結束,追蹤完
...
...
}
並且在觸發更新時,增加髒標記的判斷。
如果有多個依賴同時觸發這個 effect
,它也只會被加入佇列一次。因為一旦 dirty
變成 true
,下一次的 !sub.dirty
判斷就會是 false
跳過 if
區塊。
在 endTrack
中,sub.dirty
被設為 false
。這代表 effect
剛剛成功執行完畢,它的狀態是「乾淨的」,不需要再次執行。
//computed.ts
..
..
update(){
...
...
try {
// 更新前的值
const oldValue = this._value
// 更新的值
this._value = this.fn()
this.dirty = false// 刪除髒標記初始化
return hasChanged(oldValue, this._value)
} finally {
endTrack(this)
setActiveSub(prevSub)
}
}
..
..
清除在 computed.ts
的髒標記初始化,因為我們已經在 endTrack
函式,統一處理初始化。
effect
在執行完畢後,dirty
標記會被設為 false
,表示「這是最新狀態,不需要執行」。propagate
函式會檢查 effect
是否為 dirty: false
。dirty
為 false
時,才會將馬上設定為 true
,然後再將 effect
加入待執行佇列。true
再入列」的機制,可以保證在同一個事件迴圈中,縱使有多個依賴項目觸發同一個 effect
,它也只會被加入佇列一次,避免了不必要的重複執行。同步更新《嘿,日安!》技術部落格